--- /dev/null
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=simple-captive-portal
+PKG_VERSION:=2025.06.22
+PKG_RELEASE:=1
+
+PKG_LICENSE:=GPL-2.0-or-later
+
+include $(INCLUDE_DIR)/package.mk
+
+Build/Compile=
+
+define Package/simple-captive-portal
+ SUBMENU:=Captive Portals
+ SECTION:=net
+ CATEGORY:=Network
+ TITLE:=Simple captive portal
+ PKGARCH:=all
+ DEPENDS:=+uhttpd +uhttpd-mod-lua +luci-lib-ip
+endef
+
+define Package/simple-captive-portal/install
+ $(INSTALL_DIR) $(1)/etc/config
+ $(INSTALL_CONF) ./files/etc/config/simple-captive-portal $(1)/etc/config/simple-captive-portal
+ $(INSTALL_DIR) $(1)/etc/hotplug.d/net/
+ $(INSTALL_DATA) ./files/etc/hotplug.d/net/00-simple-captive-portal $(1)/etc/hotplug.d/net/00-simple-captive-portal
+ $(INSTALL_DIR) $(1)/etc/init.d
+ $(INSTALL_BIN) ./files/etc/init.d/simple-captive-portal $(1)/etc/init.d/simple-captive-portal
+ $(INSTALL_DIR) $(1)/etc/simple-captive-portal/
+ $(INSTALL_DATA) ./files/etc/simple-captive-portal/index.html $(1)/etc/simple-captive-portal/index.html
+ $(INSTALL_DIR) $(1)/usr/share/simple-captive-portal/
+ $(INSTALL_DATA) ./files/usr/share/simple-captive-portal/capabilities.json $(1)/usr/share/simple-captive-portal/capabilities.json
+ $(INSTALL_DATA) ./files/usr/share/simple-captive-portal/portal.lua $(1)/usr/share/simple-captive-portal/portal.lua
+ $(INSTALL_DATA) ./files/usr/share/simple-captive-portal/redirect.lua $(1)/usr/share/simple-captive-portal/redirect.lua
+endef
+
+define Package/simple-captive-portal/conffiles
+/etc/simple-captive-portal/
+/etc/config/simple-captive-portal
+endef
+
+define Package/simple-captive-portal/description
+ Provides a simple captive portal splash page.
+endef
+
+$(eval $(call BuildPackage,simple-captive-portal))
--- /dev/null
+# Simple captive portal
+
+This package intercepts/blocks traffic from 'interface' and
+redirects http requests to a splash page that you can personalize,
+stored in '/etc/simple-captive-portal/'.
+After clicking on 'connect' the MAC of the client is allowed,
+for 'timeout' seconds (24h), allowing both IPv4 and IPv6.
+
+If your guest interface defaults to input drop or reject (recommended),
+make sure to allow tcp 8888-8889 on input (and also dns and dhcp).
+
+Here an example (ipv4) firewall configuration.
+
+```
+config zone
+ option name 'guest'
+ option forward 'REJECT'
+ option output 'ACCEPT'
+ option input 'REJECT'
+ option network 'guest'
+
+config forwarding
+ option dest 'wans'
+ option src 'guest'
+
+config rule
+ option name 'guest-dhcp'
+ option src 'guest'
+ option family 'ipv4'
+ option proto 'udp'
+ option dest_port '67'
+ option target 'ACCEPT'
+
+config rule
+ option name 'guest-dns'
+ option src 'guest'
+ option family 'ipv4'
+ list proto 'tcp'
+ list proto 'udp'
+ option dest_port '53'
+ option target 'ACCEPT'
+
+config rule
+ option name 'guest-portal'
+ option src 'guest'
+ option family 'ipv4'
+ list proto 'tcp'
+ option dest_port '8888-8889'
+ option target 'ACCEPT'
+```
+
+To disable simple-captive-portal, just unset/comment 'interface' in the uci config.
--- /dev/null
+config simple-captive-portal main
+ #option interface guest
+ #option port_redirect 8888
+ #option port_portal 8889
+ #option timeout 86400
--- /dev/null
+INTF=$(uci -q get simple-captive-portal.main.interface)
+
+if [ "$ACTION" = add -a "$DEVICENAME" == "$INTF" ]; then
+ /etc/init.d/simple-captive-portal firewall
+fi
--- /dev/null
+#!/bin/sh /etc/rc.common
+START=10
+USE_PROCD=1
+EXTRA_COMMANDS='firewall'
+
+firewall() {
+ local INTF PORT_REDIRECT TIMEOUT
+ config_load "simple-captive-portal"
+ config_get INTF main interface
+ [ -z "${INTF}" ] && exit 0
+ config_get PORT_REDIRECT main port_redirect 8888
+ config_get TIMEOUT main timeout 86400
+
+ /usr/sbin/nft -f- <<EOF
+table inet simple-captive-portal
+flush table inet simple-captive-portal
+table inet simple-captive-portal {
+ set guest_macs {
+ type ether_addr
+ timeout ${TIMEOUT}s
+ }
+
+ chain prerouting {
+ type nat hook prerouting priority mangle; policy drop;
+ iif != ${INTF} accept
+ ether saddr @guest_macs accept
+ tcp dport 80 redirect to ${PORT_REDIRECT}
+ fib daddr . iif type { local, broadcast, multicast } accept
+ reject
+ }
+}
+EOF
+}
+
+boot() {
+ BOOT=1
+ start "$@"
+}
+
+start_service() {
+ # firewall() called by hotplug on boot
+ [ -z "${BOOT}" ] && firewall
+
+ local INTF PORT_REDIRECT PORT_PORTAL
+ config_load "simple-captive-portal"
+ config_get INTF main interface
+ [ -z "${INTF}" ] && exit 0
+ config_get PORT_REDIRECT main port_redirect 8888
+ config_get PORT_PORTAL main port_portal 8889
+
+ procd_open_instance redirect
+ procd_set_param command /usr/sbin/uhttpd -f -c /dev/null -k0 -h /etc/simple-captive-portal/ -l / -L /usr/share/simple-captive-portal/redirect.lua -p "${PORT_REDIRECT}"
+ procd_set_param env PORT_PORTAL=${PORT_PORTAL}
+ procd_add_jail simple-captive-portal-redirect log procfs sysfs ronly
+ procd_add_jail_mount /etc/simple-captive-portal/
+ procd_add_jail_mount /usr/share/simple-captive-portal/redirect.lua
+ procd_add_jail_mount /usr/lib/uhttpd_lua.so
+ procd_set_param user nobody
+ procd_set_param no_new_privs
+ procd_set_param respawn
+ procd_set_param stdout 1
+ procd_set_param stderr 1
+ procd_close_instance
+
+ procd_open_instance portal
+ procd_set_param command /usr/sbin/uhttpd -f -c /dev/null -k0 -h /etc/simple-captive-portal/ -l /connect -L /usr/share/simple-captive-portal/portal.lua -p "${PORT_PORTAL}"
+ procd_add_jail simple-captive-portal-portal log procfs sysfs ronly
+ procd_add_jail_mount /etc/simple-captive-portal/
+ procd_add_jail_mount /usr/lib/lua/luci/ip.so
+ procd_add_jail_mount /usr/share/simple-captive-portal/portal.lua
+ procd_add_jail_mount /usr/lib/uhttpd_lua.so
+ procd_add_jail_mount /usr/sbin/nft
+ procd_add_jail_mount /bin/sh
+ procd_set_param capabilities /usr/share/simple-captive-portal/capabilities.json
+ procd_set_param user nobody
+ procd_set_param no_new_privs
+ procd_set_param respawn
+ procd_set_param stdout 1
+ procd_set_param stderr 1
+ procd_close_instance
+}
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Simple captive portal</title>
+</head>
+<body style="text-align:center">
+<a href="/connect">
+<h1>Free Wifi</h1>
+<p>Click here to connect</p>
+</a>
+</body>
+</html>
--- /dev/null
+{
+ "bounding": [
+ "CAP_NET_ADMIN"
+ ],
+ "effective": [
+ "CAP_NET_ADMIN"
+ ],
+ "ambient": [
+ "CAP_NET_ADMIN"
+ ],
+ "permitted": [
+ "CAP_NET_ADMIN"
+ ],
+ "inheritable": [
+ "CAP_NET_ADMIN"
+ ]
+}
--- /dev/null
+require "luci.ip"
+
+function handle_request(env)
+ local mac = nil
+ luci.ip.neighbors({ dest = env.REMOTE_ADDR }, function(n) mac = n.mac end)
+ if mac == nil then
+ uhttpd.send("Status: 500 Internal Server Error\r\n")
+ uhttpd.send("Server: simple-captive-portal\r\n")
+ uhttpd.send("Content-Type: text/plain\r\n\r\n")
+ uhttpd.send("ERROR: MAC not found for IP " .. env.REMOTE_ADDR)
+ return
+ end
+
+ ret = os.execute("nft add element inet simple-captive-portal guest_macs { " .. tostring(mac) .. " }")
+ if ret ~= 0 then
+ uhttpd.send("Status: 500 Internal Server Error\r\n")
+ uhttpd.send("Server: simple-captive-portal\r\n")
+ uhttpd.send("Content-Type: text/plain\r\n\r\n")
+ uhttpd.send("ERROR: failed to add mac to set\n")
+ return
+ end
+
+ uhttpd.send("Status: 200 OK\r\n")
+ uhttpd.send("Server: simple-captive-portal\r\n")
+ uhttpd.send("Content-Type: text/plain\r\n\r\n")
+ uhttpd.send("You now have internet access\n")
+end
--- /dev/null
+port_portal = os.getenv("PORT_PORTAL")
+
+function handle_request(env)
+ uhttpd.send("Status: 302 Found\r\n")
+ uhttpd.send("Server: simple-captive-portal\r\n")
+ if string.find(env.SERVER_ADDR, ":") == nil then
+ uhttpd.send("Location: http://" .. env.SERVER_ADDR .. ":" .. port_portal .. "/\r\n")
+ else
+ uhttpd.send("Location: http://[" .. env.SERVER_ADDR .. "]:" .. port_portal .. "/\r\n")
+ end
+ uhttpd.send("Cache-Control: no-cache\r\n")
+ uhttpd.send("Content-Length: 0\r\n\r\n")
+end